Skip to content

Fix: CLOUD-3N2A#898

Open
claudear wants to merge 1 commit into
mainfrom
fix/start-transaction-stale-rollback
Open

Fix: CLOUD-3N2A#898
claudear wants to merge 1 commit into
mainfrom
fix/start-transaction-stale-rollback

Conversation

@claudear

@claudear claudear commented Jun 18, 2026

Copy link
Copy Markdown

Resolves CLOUD-3N2A (sentry 7536819395).

PR opened by backfill from pushed branch fix/start-transaction-stale-rollback.

Summary by CodeRabbit

  • Bug Fixes
    • Improved reliability of database transactions when recovering from stale connection state issues.
    • Enhanced error handling during transaction retry scenarios to prevent state inconsistencies.

When a persistent PDO connection's transaction state is out of sync
with the server (e.g. implicit commit, server-side timeout, connection
reset), the defensive rollBack() in startTransaction() would throw
"There is no active transaction" and prevent any new transaction from
being created. This wraps the cleanup rollback in a try-catch so the
failure is silently ignored and a fresh transaction can proceed.

Also ensures withTransaction() resets the inTransaction counter
immediately when rollbackTransaction() fails during a retry-eligible
attempt, preventing the next startTransaction() from incorrectly
taking the savepoint branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

In Adapter::withTransaction(), $this->inTransaction is now reset to 0 before the retry continue when rollbackTransaction() itself throws. In SQL::startTransaction() and Postgres::startTransaction(), the pre-begin rollback cleanup is wrapped in try/catch(PDOException) to silently ignore stale-connection failures. A new TransactionTest file adds two mocked-PDO regression tests covering both fixes.

Changes

Transaction State Defensive Fixes

Layer / File(s) Summary
Defensive PDOException guard in startTransaction()
src/Database/Adapter/SQL.php, src/Database/Adapter/Postgres.php
Both adapters now wrap the pre-begin rollback/ROLLBACK cleanup in try/catch(PDOException), silently ignoring failures from stale persistent connections or implicit-commit scenarios before calling beginTransaction().
inTransaction counter reset ordering fix and regression tests
src/Database/Adapter.php, tests/unit/TransactionTest.php
In withTransaction(), $this->inTransaction is reset to 0 before the retry continue when rollbackTransaction() throws. New TransactionTest covers startTransaction() stale-flag recovery and withTransaction() retry counter reset using mocked PDO.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • utopia-php/database#677: Modifies withTransaction() retry/rollback control flow and SQL::startTransaction() behavior in the same files touched by this PR.
  • utopia-php/database#748: Resets inTransaction on PDOException errors in SQL.php rollback/reconnect bookkeeping, directly aligned with the counter-reset fix here.
  • utopia-php/database#895: Updates SQL::startTransaction() to wrap the pre-begin rollback cleanup in try/catch(PDOException), the same pattern introduced in this PR.

Suggested reviewers

  • abnegate

Poem

🐇 A rollback may stumble and throw up a fuss,
But now we reset before making a fuss.
The counter goes zero, the retry goes on,
Stale PDO connections? Their errors are gone.
Two tests stand as guard — the bugs shall not pass! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title 'Fix: CLOUD-3N2A' is unrelated to the actual changes. It only references a ticket ID without conveying what the PR actually fixes about transaction handling. Revise the title to describe the core change, e.g., 'Fix transaction handling for stale PDO connection states' or 'Fix: Gracefully handle stale transaction state in persistent connections'.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/start-transaction-stale-rollback
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/start-transaction-stale-rollback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

  • Updates transaction startup and retry cleanup for stale rollback cases.
  • Resets the adapter transaction counter before retrying after rollback failure.
  • Ignores defensive pre-begin rollback failures in SQL and Postgres adapters.
  • Adds unit tests for stale PDO transaction flags and retry recovery.

Confidence Score: 4/5

The transaction recovery changes need attention before merge because rollback failures can be mishandled in paths outside the targeted stale-flag case.

The affected code is concentrated in transaction lifecycle handling, and focused runtime checks confirmed key failure modes around retrying after rollback errors and swallowing unexpected PDO rollback exceptions.

src/Database/Adapter.php, src/Database/Adapter/SQL.php, and the corresponding Postgres rollback cleanup path need attention.

T-Rex T-Rex Logs

What T-Rex did

  • I created a focused PHP repro harness that uses the repository adapter transaction API with nested withTransaction calls and a forced savepoint rollback failure, but the environment prevented running it due to missing PHP runtime and dependencies.
  • I executed the focused PHP harness using the Memory adapter to exercise unsafe rollback retry path; the run showed a first callback that wrote state, a transient error, and a rollback replay that mutated persisted state, with a second retry that started from the dirty persisted state and produced two callback attempts with a final inconsistent state.
  • I executed the focused PHP harness against the real MariaDB adapter path using SQL::startTransaction with a fake PDO reporting an active transaction and throwing a rollback protocol error; the runtime trace showed inTransaction, a rollBack failure, then beginTransaction being called, with no exception returned to the caller.
  • T-Rex ran the requested verification, but its local artifact references were not uploaded.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (1): Last reviewed commit: "fix: catch stale-rollback PDOException i..." | Re-trigger Greptile

Comment thread src/Database/Adapter.php
try {
$this->rollbackTransaction();
} catch (\Throwable $rollback) {
$this->inTransaction = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Nested retry breaks state

This reset also runs when the failing rollback belongs to an inner savepoint. In a nested withTransaction(), a transient error in the inner callback can make the savepoint rollback fail, reset inTransaction to 0, and then retry the inner callback as a new top-level transaction. That retry can issue the top-level cleanup rollback and abort the outer transaction, while the outer callback continues and later commits or no-ops with invalid state. This can commit inner work independently and lose the outer transaction's atomicity.

Comment thread src/Database/Adapter.php
Comment on lines +439 to 443
$this->inTransaction = 0;

if ($attempts < $retries) {
\usleep($sleep * ($attempts + 1));
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unsafe rollback retry

This path resets inTransaction and retries the callback after any rollback exception. For adapters that inherit this base method, a rollback failure can mean the rollback only partially completed rather than that the transaction counter is stale. For example, Memory::rollbackTransaction() replays inverse callbacks; if one inverse throws after changing state, this branch clears the counter and runs the user callback again on dirty state, which can duplicate side effects or leave persisted data inconsistent. Only retry when the rollback failure is the known stale-transaction case, otherwise surface the rollback failure.

Comment on lines +83 to 93
try {
if ($this->getPDO()->inTransaction()) {
$this->getPDO()->rollBack();
} else {
$this->getPDO()->prepare('ROLLBACK')->execute();
}
} catch (PDOException) {
// Defensive cleanup — if there is nothing to roll back
// (stale persistent connection, implicit commit, etc.)
// we can safely ignore the failure and proceed.
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unexpected rollback errors hidden

The defensive rollback handling in both src/Database/Adapter/SQL.php and src/Database/Adapter/Postgres.php suppresses every PDOException raised while cleaning up before beginTransaction(). That is safe for the targeted stale “no active transaction” case, but it also hides real cleanup failures such as connection, protocol, or server errors. When that happens, beginTransaction() can run after a failed cleanup attempt and callers can execute on an unknown transaction state with the original rollback error discarded. Please only ignore the specific benign stale-transaction error and rethrow other PDOExceptions.

Artifacts

Repro: focused PHP harness using the real SQL adapter path with a fake PDO call trace

  • Contains supporting evidence from the run (text/x-php; charset=utf-8).

Repro: execution output showing rollback error swallowed and beginTransaction called

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/unit/TransactionTest.php (1)

59-77: ⚡ Quick win

Strengthen retry-branch assertions in this regression test.

This currently proves retry happened, but not that retry took the fresh-transaction path (instead of savepoint flow). Add explicit PDO expectations for beginTransaction() count and no exec('SAVEPOINT ...') calls.

Suggested assertion hardening
-        $pdoMock->method('beginTransaction')
-            ->willReturn(true);
+        $pdoMock->expects($this->exactly(2))
+            ->method('beginTransaction')
+            ->willReturn(true);
+
+        $pdoMock->expects($this->never())
+            ->method('exec');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/TransactionTest.php` around lines 59 - 77, The test currently
verifies that a retry occurred but does not prove the retry took the
fresh-transaction path instead of using savepoints. Add an explicit expectation
to the pdoMock for the beginTransaction() method to assert it is called exactly
twice (the initial transaction attempt plus the retry after rollBack fails).
Additionally, add an expectation that the pdoMock's exec() method is never
called with arguments containing 'SAVEPOINT' to ensure the savepoint fallback
mechanism was not used.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/unit/TransactionTest.php`:
- Around line 59-77: The test currently verifies that a retry occurred but does
not prove the retry took the fresh-transaction path instead of using savepoints.
Add an explicit expectation to the pdoMock for the beginTransaction() method to
assert it is called exactly twice (the initial transaction attempt plus the
retry after rollBack fails). Additionally, add an expectation that the pdoMock's
exec() method is never called with arguments containing 'SAVEPOINT' to ensure
the savepoint fallback mechanism was not used.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b8e2a2a6-b03c-43a1-9bfc-cc6300981bc0

📥 Commits

Reviewing files that changed from the base of the PR and between acc0c1c and d726110.

📒 Files selected for processing (4)
  • src/Database/Adapter.php
  • src/Database/Adapter/Postgres.php
  • src/Database/Adapter/SQL.php
  • tests/unit/TransactionTest.php

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant